1 // Copyright (C) 2024 Rubén Beltrán del Río
3 // This program is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU General Public License
14 // along with this program. If not, see https://map.tranquil.systems.
18 class MapTextEditorController: NSViewController {
20 @Binding var document: MapDocument
21 var highlightRanges: [Range<String.Index>] {
27 var selectedRange: Int {
34 let onChange: () -> Void
36 private let vertexRegex = MapParsingPatterns.vertex
37 private let edgeRegex = MapParsingPatterns.edge
38 private let blockerRegex = MapParsingPatterns.blocker
39 private let opportunityRegex = MapParsingPatterns.opportunity
40 private let noteRegex = MapParsingPatterns.note
41 private let stageRegex = MapParsingPatterns.stage
42 private let groupRegex = MapParsingPatterns.group
44 private let changeDebouncer: Debouncer = Debouncer(seconds: 1)
47 document: Binding<MapDocument>, highlightRanges: [Range<String.Index>], selectedRange: Int,
48 onChange: @escaping () -> Void
50 self._document = document
51 self.onChange = onChange
52 self.highlightRanges = highlightRanges
53 self.selectedRange = selectedRange
54 super.init(nibName: nil, bundle: nil)
57 required init?(coder: NSCoder) {
58 fatalError("init(coder:) has not been implemented")
61 override func loadView() {
62 let scrollView = NSTextView.scrollableTextView()
63 let textView = scrollView.documentView as! NSTextView
65 scrollView.translatesAutoresizingMaskIntoConstraints = false
67 textView.backgroundColor = .UI.background
68 textView.allowsUndo = true
69 textView.delegate = self
70 textView.textStorage?.delegate = self
71 textView.string = self.document.text
72 textView.isEditable = true
73 textView.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular)
74 self.view = scrollView
77 override func viewDidAppear() {
78 self.view.window?.makeFirstResponder(self.view)
82 private var textView: NSTextView? {
83 return (view as? NSScrollView)?.documentView as? NSTextView
86 private func updateHighlights() {
88 if let textStorage = textView.textStorage {
89 textStorage.removeAttribute(
90 .backgroundColor, range: NSRange(location: 0, length: textStorage.length))
92 for (index, range) in highlightRanges.enumerated() {
93 let nsRange = NSRange(range, in: textStorage.string)
95 let color = index == selectedRange ? NSColor.Syntax.highlightMatch : NSColor.Syntax.match
96 textStorage.addAttribute(.backgroundColor, value: color, range: nsRange)
99 textView.needsDisplay = true
105 private func focusOnResult() {
107 if let textStorage = textView.textStorage {
108 if selectedRange < highlightRanges.count {
109 let range = highlightRanges[selectedRange]
110 let nsRange = NSRange(range, in: textStorage.string)
111 textView.scrollRangeToVisible(nsRange)
118 extension MapTextEditorController: NSTextViewDelegate {
120 func textDidChange(_ obj: Notification) {
121 if let textField = obj.object as? NSTextView {
122 self.document.text = textField.string
124 changeDebouncer.debounce {
125 DispatchQueue.main.async {
132 func textView(_ view: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool
134 let range = Range(shouldChangeTextIn, in: view.string)
135 let target = view.string[range!]
145 extension MapTextEditorController: NSTextStorageDelegate {
147 override func textStorageDidProcessEditing(_ obj: Notification) {
148 if let textStorage = obj.object as? NSTextStorage {
149 self.colorizeText(textStorage: textStorage)
153 private func colorizeText(textStorage: NSTextStorage) {
154 let range = NSMakeRange(0, textStorage.length)
155 var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range)
157 for match in matches {
158 textStorage.addAttributes(
159 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 1))
160 textStorage.addAttributes(
161 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 2))
162 textStorage.addAttributes(
163 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 3))
164 textStorage.addAttributes(
165 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 4))
168 matches = edgeRegex.matches(in: textStorage.string, options: [], range: range)
170 for match in matches {
171 textStorage.addAttributes(
172 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 1))
173 let arrowRange = match.range(at: 2)
174 textStorage.addAttributes(
175 [.foregroundColor: NSColor.Syntax.symbol],
176 range: NSMakeRange(arrowRange.lowerBound - 1, arrowRange.length + 1))
177 textStorage.addAttributes(
178 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 3))
181 matches = opportunityRegex.matches(in: textStorage.string, options: [], range: range)
183 for match in matches {
184 textStorage.addAttributes(
185 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
186 textStorage.addAttributes(
187 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 2))
188 textStorage.addAttributes(
189 [.foregroundColor: NSColor.Syntax.symbol], range: match.range(at: 3))
190 textStorage.addAttributes(
191 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 4))
194 matches = blockerRegex.matches(in: textStorage.string, options: [], range: range)
196 for match in matches {
197 textStorage.addAttributes(
198 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
199 textStorage.addAttributes(
200 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 2))
203 matches = noteRegex.matches(in: textStorage.string, options: [], range: range)
205 for match in matches {
206 textStorage.addAttributes(
207 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
208 textStorage.addAttributes(
209 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 2))
210 textStorage.addAttributes(
211 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 3))
214 matches = stageRegex.matches(in: textStorage.string, options: [], range: range)
216 for match in matches {
217 textStorage.addAttributes(
218 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
219 textStorage.addAttributes(
220 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 2))
223 matches = groupRegex.matches(in: textStorage.string, options: [], range: range)
225 for match in matches {
226 textStorage.addAttributes(
227 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
228 textStorage.addAttributes(
229 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 2))
234 struct MapTextEditor: NSViewControllerRepresentable {
236 @Binding var document: MapDocument
237 var highlightRanges: [Range<String.Index>]
238 var selectedRange: Int
239 var onChange: () -> Void = {}
241 func makeNSViewController(
242 context: NSViewControllerRepresentableContext<MapTextEditor>
243 ) -> MapTextEditorController {
244 return MapTextEditorController(
245 document: $document, highlightRanges: highlightRanges, selectedRange: selectedRange,
249 func updateNSViewController(
250 _ nsViewController: MapTextEditorController,
251 context: NSViewControllerRepresentableContext<MapTextEditor>
253 nsViewController.highlightRanges = highlightRanges
254 if nsViewController.selectedRange != selectedRange {
255 nsViewController.selectedRange = selectedRange